<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>2024新年快乐！万事如意！</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
    <meta name="mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="theme-color" content="#000000">
    <link rel="shortcut icon" type="image/png"
        href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
    <link rel="icon" type="image/png"
        href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
    <link rel="apple-touch-icon-precomposed"
        href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
    <meta name="msapplication-TileColor" content="#000000">
    <meta name="msapplication-TileImage"
        content="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
    <link href="https://fonts.googleapis.com/css?family=Russo+One" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css">
    <link rel="stylesheet" href="./style.css">
    <style>
        * {
            position: relative;
            box-sizing: border-box;
        }

        html,
        body {
            height: 100%;
        }

        html {
            background-color: #000;
        }

        body {
            overflow: hidden;
            color: rgba(255, 255, 255, 0.5);
            font-family: "Russo One", arial, sans-serif;
            line-height: 1.25;
            letter-spacing: 0.06em;
        }

        .hide {
            opacity: 0;
            visibility: hidden;
        }

        .remove {
            display: none;
        }

        .blur {
            filter: blur(12px);
        }

        .container {
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
        }

        #loading-init {
            width: 100%;
            align-self: center;
            text-align: center;
            font-size: 2em;
        }

        #stage-container {
            overflow: hidden;
            box-sizing: initial;
            border: 1px solid #222;
            margin: -1px;
        }

        #canvas-container {
            width: 100%;
            height: 100%;
            transition: filter 0.3s;

        }

        #canvas-container canvas {
            position: absolute;
            mix-blend-mode: lighten;
        }

        #controls {
            position: absolute;
            top: 0;
            width: 100%;
            padding-bottom: 50px;
            display: flex;
            justify-content: space-between;
            transition: opacity 0.3s, visibility 0.3s;
        }

        @media (min-width: 800px) {
            #controls {
                visibility: visible;
            }

            #controls.hide:hover {
                opacity: 1;
            }
        }

        #menu {
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            position: absolute;
            top: 0;
            bottom: 0;
            width: 100%;
            background-color: rgba(0, 0, 0, 0.42);
            transition: opacity 0.3s, visibility 0.3s;
        }

        #menu__header {
            padding: 20px 0 44px;
            font-size: 2em;
            text-transform: uppercase;
        }

        #menu form {
            width: 240px;
            padding: 0 20px;
            overflow: auto;
        }

        #menu .form-option {
            margin: 20px 0;
        }

        #menu .form-option label {
            text-transform: uppercase;
        }

        #menu .form-option--select label {
            display: block;
            margin-bottom: 6px;
        }

        #menu .form-option--select select {
            display: block;
            width: 100%;
            height: 30px;
            font-size: 1rem;
            font-family: "Russo One", arial, sans-serif;
            color: rgba(255, 255, 255, 0.5);
            letter-spacing: 0.06em;
            background-color: transparent;
            border: 1px solid rgba(255, 255, 255, 0.5);
        }

        #menu .form-option--select select option {
            background-color: black;
        }

        #menu .form-option--checkbox label {
            display: flex;
            align-items: center;
            transition: opacity 0.3s;
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
        }

        #menu .form-option--checkbox input {
            display: block;
            width: 20px;
            height: 20px;
            margin-right: 8px;
            opacity: 0.5;
        }

        @media (max-width: 800px) {

            #menu .form-option select,
            #menu .form-option input {
                outline: none;
            }
        }

        #close-menu-btn {
            position: absolute;
            top: 0;
            right: 0;
        }

        .btn {
            opacity: 0.16;
            width: 44px;
            height: 44px;
            display: flex;
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
            cursor: default;
            transition: opacity 0.3s;
        }

        .btn--bright {
            opacity: 0.5;
        }

        @media (min-width: 800px) {
            .btn:hover {
                opacity: 0.32;
            }

            .btn--bright:hover {
                opacity: 0.75;
            }
        }

        .btn svg {
            display: block;
            margin: auto;
        }
    </style>
</head>

<body>
    <!-- partial:index.partial.html -->
    <!-- SVG Spritesheet -->
    <div style="height: 0; width: 0; position: absolute; visibility: hidden;">
        <svg xmlns="http://www.w3.org/2000/svg">
            <symbol id="icon-play" viewBox="0 0 24 24">
                <path d="M8 5v14l11-7z" />
            </symbol>
            <symbol id="icon-pause" viewBox="0 0 24 24">
                <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
            </symbol>
            <symbol id="icon-close" viewBox="0 0 24 24">
                <path
                    d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
            </symbol>
            <symbol id="icon-settings" viewBox="0 0 24 24">
                <path
                    d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z" />
            </symbol>
            <symbol id="icon-shutter-fast" viewBox="0 0 24 24">
                <path
                    d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
            </symbol>
            <symbol id="icon-shutter-slow" viewBox="0 0 24 24">
                <path
                    d="M1 5h2v14H1zm4 0h2v14H5zm17 0H10c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1zM11 17l2.5-3.15L15.29 16l2.5-3.22L21 17H11z" />
            </symbol>
        </svg>
    </div>

    <!-- App -->
    <div class="container">
        <div id="loading-init">Loading...</div>
        <div id="stage-container" class="remove">
            <!-- <h1 style="">2024新年快乐</h1> -->
            <div id="canvas-container">
                <canvas id="trails-canvas"></canvas>
                <canvas id="main-canvas"></canvas>
            </div>
            <div id="controls">
                <div id="pause-btn" class="btn">
                    <svg fill="white" width="24" height="24">
                        <use href="#icon-pause"></use>
                    </svg>
                </div>
                <div id="shutter-btn" class="btn">
                    <svg fill="white" width="24" height="24">
                        <use href="#icon-shutter-slow"></use>
                    </svg>
                </div>
                <div id="settings-btn" class="btn">
                    <svg fill="white" width="24" height="24">
                        <use href="#icon-settings"></use>
                    </svg>
                </div>
            </div>
            <div id="menu" class="hide">
                <div id="close-menu-btn" class="btn btn--bright">
                    <svg fill="white" width="24" height="24">
                        <use href="#icon-close"></use>
                    </svg>
                </div>
                <div id="menu__header">Settings</div>
                <form>
                    <div class="form-option form-option--select">
                        <label>Shell Type</label>
                        <select id="shell-type"></select>
                    </div>
                    <div class="form-option form-option--select">
                        <label>Shell Size</label>
                        <select id="shell-size"></select>
                    </div>
                    <div class="form-option form-option--checkbox">
                        <label id="auto-launch-label"><input id="auto-launch" type="checkbox" /><span>Auto
                                Fire</span></label>
                    </div>
                    <div class="form-option form-option--checkbox">
                        <label id="finale-mode-label"><input id="finale-mode" type="checkbox" /><span>Finale
                                Mode</span></label>
                    </div>
                    <div class="form-option form-option--checkbox">
                        <label id="hide-controls-label"><input id="hide-controls" type="checkbox" /><span>隐藏控件</span></label>
                    </div>
                </form>
            </div>
        </div>
    </div>
    <!-- partial -->
    <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/fscreen%401.0.1.js'></script>
    <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/Stage%400.1.4.js'></script>
    <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/MyMath.js'></script>
    <script>
        'use strict';
        console.clear();


        const IS_MOBILE = window.innerWidth <= 640;
        const IS_DESKTOP = window.innerWidth > 800;
        const IS_HEADER = IS_DESKTOP && window.innerHeight < 300;
        // 8K - can restrict this if needed
        const MAX_WIDTH = 7680;
        const MAX_HEIGHT = 4320;
        const GRAVITY = 0.9; // Acceleration in px/s
        let simSpeed = 1;

        const COLOR = {
            Red: '#ff0043',
            Green: '#14fc56',
            Blue: '#1e7fff',
            Purple: '#e60aff',
            Gold: '#ffae00',
            White: '#ffffff'
        };

        // Special invisible color (not rendered, and therefore not in COLOR map)
        const INVISIBLE = '_INVISIBLE_';


        // Interactive state management
        const store = {
            _listeners: new Set(),
            _dispatch() {
                this._listeners.forEach(listener => listener(this.state))
            },
            state: {
                paused: false,
                longExposure: false,
                menuOpen: false,
                config: {
                    shell: 'Random',
                    size: IS_DESKTOP && !IS_HEADER ? '3' : '1',
                    autoLaunch: true,
                    finale: false,
                    hideControls: IS_HEADER
                }
            },
            setState(nextState) {
                this.state = Object.assign({}, this.state, nextState);
                this._dispatch();
                this.persist();
            },
            subscribe(listener) {
                this._listeners.add(listener);
                return () => this._listeners.remove(listener);
            },
            // Load / persist select state to localStorage
            load() {
                if (localStorage.getItem('schemaVersion') === '1') {
                    this.state.config.size = JSON.parse(localStorage.getItem('configSize'));
                    this.state.config.hideControls = JSON.parse(localStorage.getItem('hideControls'));
                }
            },
            persist() {
                localStorage.setItem('schemaVersion', '1');
                localStorage.setItem('configSize', JSON.stringify(this.state.config.size));
                localStorage.setItem('hideControls', JSON.stringify(this.state.config.hideControls));
            }
        };

        if (!IS_HEADER) {
            store.load();
        }

        // Actions
        // ---------

        function togglePause(toggle) {
            if (typeof toggle === 'boolean') {
                store.setState({
                    paused: toggle
                });
            } else {
                store.setState({
                    paused: !store.state.paused
                });
            }
        }

        function toggleLongExposure(toggle) {
            if (typeof toggle === 'boolean') {
                store.setState({
                    longExposure: toggle
                });
            } else {
                store.setState({
                    longExposure: !store.state.longExposure
                });
            }
        }

        function toggleMenu(toggle) {
            if (typeof toggle === 'boolean') {
                store.setState({
                    menuOpen: toggle
                });
            } else {
                store.setState({
                    menuOpen: !store.state.menuOpen
                });
            }
        }

        function updateConfig(nextConfig) {
            nextConfig = nextConfig || getConfigFromDOM();
            store.setState({
                config: Object.assign({}, store.state.config, nextConfig)
            });
        }

        // Selectors
        // -----------

        const canInteract = () => !store.state.paused && !store.state.menuOpen;
        const shellNameSelector = () => store.state.config.shell;
        // Converts shell size to number.
        const shellSizeSelector = () => +store.state.config.size;
        const finaleSelector = () => store.state.config.finale;


        // Render app UI / keep in sync with state
        const appNodes = {
            stageContainer: '#stage-container',
            canvasContainer: '#canvas-container',
            controls: '#controls',
            menu: '#menu',
            pauseBtn: '#pause-btn',
            pauseBtnSVG: '#pause-btn use',
            shutterBtn: '#shutter-btn',
            shutterBtnSVG: '#shutter-btn use',
            shellType: '#shell-type',
            shellSize: '#shell-size',
            autoLaunch: '#auto-launch',
            autoLaunchLabel: '#auto-launch-label',
            finaleMode: '#finale-mode',
            finaleModeLabel: '#finale-mode-label',
            hideControls: '#hide-controls',
            hideControlsLabel: '#hide-controls-label'
        };

        // Convert appNodes selectors to dom nodes
        Object.keys(appNodes).forEach(key => {
            appNodes[key] = document.querySelector(appNodes[key]);
        });

        // Remove loading state
        document.getElementById('loading-init').remove();
        appNodes.stageContainer.classList.remove('remove');

        // First render is called in init()
        function renderApp(state) {
            appNodes.pauseBtnSVG.setAttribute('href', `#icon-${state.paused ? 'play' : 'pause'}`);
            appNodes.shutterBtnSVG.setAttribute('href', `#icon-shutter-${state.longExposure ? 'fast' : 'slow'}`);
            appNodes.controls.classList.toggle('hide', state.menuOpen || state.config.hideControls);
            appNodes.canvasContainer.classList.toggle('blur', state.menuOpen);
            appNodes.menu.classList.toggle('hide', !state.menuOpen);
            appNodes.finaleModeLabel.style.opacity = state.config.autoLaunch ? 1 : 0.32;
            appNodes.shellType.value = state.config.shell;
            appNodes.shellSize.value = state.config.size;
            appNodes.autoLaunch.checked = state.config.autoLaunch;
            appNodes.finaleMode.checked = state.config.finale;
            appNodes.hideControls.checked = state.config.hideControls;
        }

        store.subscribe(renderApp);


        function getConfigFromDOM() {
            return {
                shell: appNodes.shellType.value,
                size: appNodes.shellSize.value,
                autoLaunch: appNodes.autoLaunch.checked,
                finale: appNodes.finaleMode.checked,
                hideControls: appNodes.hideControls.checked
            };
        };

        const updateConfigNoEvent = () => updateConfig();
        appNodes.shellType.addEventListener('input', updateConfigNoEvent);
        appNodes.shellSize.addEventListener('input', updateConfigNoEvent);
        appNodes.autoLaunchLabel.addEventListener('click', () => setTimeout(updateConfig, 0));
        appNodes.finaleModeLabel.addEventListener('click', () => setTimeout(updateConfig, 0));
        appNodes.hideControlsLabel.addEventListener('click', () => setTimeout(updateConfig, 0));


        // Constant derivations
        const COLOR_NAMES = Object.keys(COLOR);
        const COLOR_CODES = COLOR_NAMES.map(colorName => COLOR[colorName]);
        // Invisible stars need an indentifier, even through they won't be rendered - physics still apply.
        const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE];
        // Tuples is a map keys by color codes (hex) with values of { r, g, b } tuples (still just objects).
        const COLOR_TUPLES = {};
        COLOR_CODES.forEach(hex => {
            COLOR_TUPLES[hex] = {
                r: parseInt(hex.substr(1, 2), 16),
                g: parseInt(hex.substr(3, 2), 16),
                b: parseInt(hex.substr(5, 2), 16),
            };
        });

        // Get a random color.
        function randomColorSimple() {
            return COLOR_CODES[Math.random() * COLOR_CODES.length | 0];
        }

        // Get a random color, with some customization options available.
        let lastColor;

        function randomColor(options) {
            const notSame = options && options.notSame;
            const notColor = options && options.notColor;
            const limitWhite = options && options.limitWhite;
            let color = randomColorSimple();
            // limit the amount of white chosen randomly
            if (limitWhite && color === COLOR.White && Math.random() < 0.6) {
                color = randomColorSimple();
            }
            if (notSame) {
                while (color === lastColor) {
                    color = randomColorSimple();
                }
            } else if (notColor) {
                while (color === notColor) {
                    color = randomColorSimple();
                }
            }
            lastColor = color;
            return color;
        }

        function whiteOrGold() {
            return Math.random() < 0.5 ? COLOR.Gold : COLOR.White;
        }

        const PI_2 = Math.PI * 2;
        const PI_HALF = Math.PI * 0.5;

        const trailsStage = new Stage('trails-canvas');
        const mainStage = new Stage('main-canvas');
        const stages = [
            trailsStage,
            mainStage
        ];

        // Fill trails canvas with black to start.
        trailsStage.ctx.fillStyle = '#000';
        trailsStage.ctx.fillRect(0, 0, trailsStage.width, trailsStage.height);


        // Fullscreen helpers, using Fscreen for prefixes
        function requestFullscreen() {
            if (fullscreenEnabled() && !isFullscreen()) {
                fscreen.requestFullscreen(document.documentElement);
            }
        }

        function fullscreenEnabled() {
            return fscreen.fullscreenEnabled;
        }

        function isFullscreen() {
            return !!fscreen.fullscreenElement;
        }


        // Shell helpers
        function makePistilColor(shellColor) {
            return (shellColor === COLOR.White || shellColor === COLOR.Gold) ? randomColor({
                notColor: shellColor
            }) : whiteOrGold();
        }

        // Unique shell types
        //生成菊花状的烟花效果
        const crysanthemumShell = (size = 1) => {
            const glitter = Math.random() < 0.25; //是否产生闪光效果
            const singleColor = Math.random() < 0.68; //是否使用单一颜色
            //一个颜色数组，包含1到2个颜色值。如果singleColor为真，则该数组仅包含一个颜色；否则该数组将包含两个不同的颜色。颜色值通过调用randomColor函数随机生成
            const color = singleColor ? randomColor({
                limitWhite: true
            }) : [randomColor(), randomColor({
                notSame: true
            })];
            const pistil = singleColor && Math.random() < 0.42; //是否绘制花蕊
            const pistilColor = makePistilColor(color); //绘制花蕊,花蕊的颜色
            const streamers = !pistil && color !== COLOR.White && Math.random() < 0.42; //是否绘制流星效果
            return {
                size: 300 + size * 100, //烟花的大小
                starLife: 900 + size * 200, //星星效果的寿命
                starDensity: glitter ? 1.1 : 1.5, //星星效果的密度
                color,
                glitter: glitter ? 'light' : '', //闪光效果的类型
                glitterColor: whiteOrGold(), //绘制闪光效果
                pistil,
                pistilColor,
                streamers
            };
        };

        //生成棕榈树状的烟花效果
        const palmShell = (size = 1) => ({
            size: 250 + size * 75,
            starDensity: 0.6,
            starLife: 1800 + size * 200,
            glitter: 'heavy'
        });

        //用于生成环状的烟花效果
        const ringShell = (size = 1) => {
            const color = randomColor();
            const pistil = Math.random() < 0.75;
            return {
                ring: true,
                color,
                size: 300 + size * 100,
                starLife: 900 + size * 200,
                starCount: 2.2 * PI_2 * (size + 1),
                pistil,
                pistilColor: makePistilColor(color),
                glitter: !pistil ? 'light' : '',
                glitterColor: color === COLOR.Gold ? COLOR.Gold : COLOR.White
            };
        };

        //生成十字状的烟花效果
        const crossetteShell = (size = 1) => {
            const color = randomColor({
                limitWhite: true
            });
            return {
                size: 300 + size * 100,
                starLife: 900 + size * 200,
                starLifeVariation: 0.22,
                color,
                crossette: true,
                pistil: Math.random() < 0.5,
                pistilColor: makePistilColor(color)
            };
        };

        //生成花朵状的烟花效果
        const floralShell = (size = 1) => ({
            size: 300 + size * 120,
            starDensity: 0.38,
            starLife: 500 + size * 50,
            starLifeVariation: 0.5,
            color: Math.random() < 0.65 ? 'random' : (Math.random() < 0.15 ? randomColor() : [randomColor(),
                randomColor({
                    notSame: true
                })
            ]),
            floral: true
        });

        //生成落叶状的烟花效果
        const fallingLeavesShell = (size = 1) => ({
            color: INVISIBLE,
            size: 300 + size * 120,
            starDensity: 0.38,
            starLife: 500 + size * 50,
            starLifeVariation: 0.5,
            glitter: 'medium',
            glitterColor: COLOR.Gold,
            fallingLeaves: true
        });

        //生成柳树状烟花效果
        const willowShell = (size = 1) => ({
            size: 300 + size * 100,
            starDensity: 0.7,
            starLife: 3000 + size * 300,
            glitter: 'willow',
            glitterColor: COLOR.Gold,
            color: INVISIBLE
        });

        //生成爆裂声烟花（crackleShell）效果
        const crackleShell = (size = 1) => {
            // favor gold
            const color = Math.random() < 0.75 ? COLOR.Gold : randomColor();
            return {
                size: 380 + size * 75,
                starDensity: 1,
                starLife: 600 + size * 100,
                starLifeVariation: 0.32,
                glitter: 'light',
                glitterColor: COLOR.Gold,
                color,
                crackle: true,
                pistil: Math.random() < 0.65,
                pistilColor: makePistilColor(color)
            };
        };

        //马尾状烟花效果
        const horsetailShell = (size = 1) => {
            const color = randomColor();
            return {
                horsetail: true,
                color,
                size: 250 + size * 38,
                starDensity: 0.85 + size * 0.1,
                starLife: 2500 + size * 300,
                glitter: 'medium',
                glitterColor: Math.random() < 0.5 ? whiteOrGold() : color
            };
        };


        function randomShellName() {
            return Math.random() < 0.6 ? 'Crysanthemum' : shellNames[(Math.random() * (shellNames.length - 1) + 1) | 0];
        }

        function randomShell(size) {
            return shellTypes[randomShellName()](size);
        }

        function shellFromConfig(size) {
            return shellTypes[shellNameSelector()](size);
        }

        // Get a random shell, not including processing intensive varients
        // Note this is only random when "Random" shell is selected in config.
        // Also, this does not create the shell, only returns the factory function.
        const fastShellBlacklist = ['Falling Leaves', 'Floral', 'Willow'];

        function randomFastShell() {
            const isRandom = shellNameSelector() === 'Random';
            let shellName = isRandom ? randomShellName() : shellNameSelector();
            if (isRandom) {
                while (fastShellBlacklist.includes(shellName)) {
                    shellName = randomShellName();
                }
            }
            return shellTypes[shellName];
        }


        const shellTypes = {
            'Random': randomShell,
            'Crackle': crackleShell,
            'Crossette': crossetteShell,
            'Crysanthemum': crysanthemumShell,
            'Falling Leaves': fallingLeavesShell,
            'Floral': floralShell,
            'Horse Tail': horsetailShell,
            'Palm': palmShell,
            'Ring': ringShell,
            'Willow': willowShell
        };

        const shellNames = Object.keys(shellTypes);




        function fitShellPositionInBoundsH(position) {
            const edge = 0.18;
            return (1 - edge * 2) * position + edge;
        }

        function fitShellPositionInBoundsV(position) {
            return position * 0.75;
        }

        function getRandomShellPositionH() {
            return fitShellPositionInBoundsH(Math.random());
        }

        function getRandomShellPositionV() {
            return fitShellPositionInBoundsV(Math.random());
        }

        function getRandomShellSize() {
            const baseSize = shellSizeSelector();
            const maxVariance = Math.min(2.5, baseSize);
            const variance = Math.random() * maxVariance;
            const size = baseSize - variance;
            const height = maxVariance === 0 ? Math.random() : 1 - (variance / maxVariance);
            const centerOffset = Math.random() * (1 - height * 0.65) * 0.5;
            const x = Math.random() < 0.5 ? 0.5 - centerOffset : 0.5 + centerOffset;
            return {
                size,
                x: fitShellPositionInBoundsH(x),
                height: fitShellPositionInBoundsV(height)
            };
        }


        // Launches a shell from a user pointer event, based on state.config
        function launchShellFromConfig(event) {
            const shell = new Shell(shellFromConfig(shellSizeSelector()));
            const w = mainStage.width;
            const h = mainStage.height;
            shell.launch(
                event ? event.x / w : getRandomShellPositionH(),
                event ? 1 - event.y / h : getRandomShellPositionV()
            );
        }


        // Sequences
        // -----------

        function seqRandomShell() {
            const size = getRandomShellSize();
            const shell = new Shell(shellFromConfig(size.size));
            shell.launch(size.x, size.height);
            let extraDelay = shell.starLife;
            if (shell.fallingLeaves) {
                extraDelay = 4000;
            }
            return 900 + Math.random() * 600 + extraDelay;
        }

        function seqTwoRandom() {
            const size1 = getRandomShellSize();
            const size2 = getRandomShellSize();
            const shell1 = new Shell(shellFromConfig(size1.size));
            const shell2 = new Shell(shellFromConfig(size2.size));
            const leftOffset = Math.random() * 0.2 - 0.1;
            const rightOffset = Math.random() * 0.2 - 0.1;
            shell1.launch(0.3 + leftOffset, size1.height);
            shell2.launch(0.7 + rightOffset, size2.height);
            let extraDelay = Math.max(shell1.starLife, shell2.starLife);
            if (shell1.fallingLeaves || shell2.fallingLeaves) {
                extraDelay = 4000;
            }
            return 900 + Math.random() * 600 + extraDelay;
        }

        function seqTriple() {
            const shellType = randomFastShell();
            const baseSize = shellSizeSelector();
            const smallSize = Math.max(0, baseSize - 1.25);
            const offset = Math.random() * 0.08 - 0.04;
            const shell1 = new Shell(shellType(baseSize));
            shell1.launch(0.5 + offset, 0.7);
            const leftDelay = 1000 + Math.random() * 400;
            const rightDelay = 1000 + Math.random() * 400;
            setTimeout(() => {
                const offset = Math.random() * 0.08 - 0.04;
                const shell2 = new Shell(shellType(smallSize));
                shell2.launch(0.2 + offset, 0.1);
            }, leftDelay);
            setTimeout(() => {
                const offset = Math.random() * 0.08 - 0.04;
                const shell3 = new Shell(shellType(smallSize));
                shell3.launch(0.8 + offset, 0.1);
            }, rightDelay);
            return 4000;
        }

        function seqSmallBarrage() {
            seqSmallBarrage.lastCalled = Date.now();
            const barrageCount = IS_DESKTOP ? 11 : 5;
            const shellSize = Math.max(0, shellSizeSelector() - 2);
            const useCrysanthemum = Math.random() < 0.7;
            // (cos(x*5π+0.5π)+1)/2 is a custom wave bounded by 0 and 1 used to set varying launch heights
            function launchShell(x) {
                const isRandom = shellNameSelector() === 'Random';
                let shellType = isRandom ? (useCrysanthemum ? crysanthemumShell : randomFastShell()) : shellTypes[
                    shellNameSelector()];
                const shell = new Shell(shellType(shellSize));
                const height = (Math.cos(x * 5 * Math.PI + PI_HALF) + 1) / 2;
                shell.launch(x, height * 0.75);
            }
            let count = 0;
            let delay = 0;
            while (count < barrageCount) {
                if (count === 0) {
                    launchShell(0.5)
                    count += 1;
                } else {
                    const offset = (count + 1) / barrageCount / 2;
                    setTimeout(() => {
                        launchShell(0.5 + offset);
                        launchShell(0.5 - offset);
                    }, delay);
                    count += 2;
                }
                delay += 200;
            }
            return 3400 + barrageCount * 120;
        }
        seqSmallBarrage.cooldown = 15000;
        seqSmallBarrage.lastCalled = Date.now();


        const sequences = [
            seqRandomShell,
            seqTwoRandom,
            seqTriple,
            seqSmallBarrage
        ];


        let isFirstSeq = true;
        const finaleCount = 32;
        let currentFinaleCount = 0;

        function startSequence() {
            if (isFirstSeq) {
                isFirstSeq = false;
                const shell = new Shell(crysanthemumShell(shellSizeSelector()));
                shell.launch(0.5, 0.5);
                return 2400;
            }
            if (finaleSelector()) {
                seqRandomShell();
                if (currentFinaleCount < finaleCount) {
                    currentFinaleCount++;
                    return 170;
                } else {
                    currentFinaleCount = 0;
                    return 6000;
                }
            }
            const rand = Math.random();
            if (rand < 0.2 && Date.now() - seqSmallBarrage.lastCalled > seqSmallBarrage.cooldown) {
                return seqSmallBarrage();
            }
            if (rand < 0.6) {
                return seqRandomShell();
            } else if (rand < 0.8) {
                return seqTwoRandom();
            } else if (rand < 1) {
                return seqTriple();
            }
        }


        let activePointerCount = 0;
        let isUpdatingSpeed = false;

        function handlePointerStart(event) {
            activePointerCount++;
            const btnSize = 44;
            if (event.y < btnSize) {
                if (event.x < btnSize) {
                    togglePause();
                    return;
                }
                if (event.x > mainStage.width / 2 - btnSize / 2 && event.x < mainStage.width / 2 + btnSize / 2) {
                    toggleLongExposure();
                    return;
                }
                if (event.x > mainStage.width - btnSize) {
                    toggleMenu();
                    return;
                }
            }
            if (!canInteract()) return;
            if (updateSpeedFromEvent(event)) {
                isUpdatingSpeed = true;
            } else if (event.onCanvas) {
                launchShellFromConfig(event);
            }
        }

        function handlePointerEnd(event) {
            activePointerCount--;
            isUpdatingSpeed = false;
        }

        function handlePointerMove(event) {
            if (!canInteract()) return;
            if (isUpdatingSpeed) {
                updateSpeedFromEvent(event);
            }
        }

        function handleKeydown(event) {
            // P
            if (event.keyCode === 80) {
                togglePause();
            }
            // O
            else if (event.keyCode === 79) {
                toggleMenu();
            }
            // Esc
            else if (event.keyCode === 27) {
                toggleMenu(false);
            }
        }

        mainStage.addEventListener('pointerstart', handlePointerStart);
        mainStage.addEventListener('pointerend', handlePointerEnd);
        mainStage.addEventListener('pointermove', handlePointerMove);
        window.addEventListener('keydown', handleKeydown);
        // Try to go fullscreen upon a touch
        window.addEventListener('touchend', (event) => !IS_DESKTOP && requestFullscreen());


        function handleResize() {
            const w = window.innerWidth;
            const h = window.innerHeight;
            // Try to adopt screen size, heeding maximum sizes specified
            const containerW = Math.min(w, MAX_WIDTH);
            // On small screens, use full device height
            const containerH = w <= 420 ? h : Math.min(h, MAX_HEIGHT);
            appNodes.stageContainer.style.width = containerW + 'px';
            appNodes.stageContainer.style.height = containerH + 'px';
            stages.forEach(stage => stage.resize(containerW, containerH));
        }

        // Compute initial dimensions
        handleResize();

        window.addEventListener('resize', handleResize);


        // Dynamic globals
        let speedBarOpacity = 0;
        let autoLaunchTime = 0;

        function updateSpeedFromEvent(event) {
            if (isUpdatingSpeed || event.y >= mainStage.height - 44) {
                // On phones it's hard to hit the edge pixels in order to set speed at 0 or 1, so some padding is provided to make that easier.
                const edge = 16;
                const newSpeed = (event.x - edge) / (mainStage.width - edge * 2);
                simSpeed = Math.min(Math.max(newSpeed, 0), 1);
                // show speed bar after an update
                speedBarOpacity = 1;
                // If we updated the speed, return true
                return true;
            }
            // Return false if the speed wasn't updated
            return false;
        }


        // Extracted function to keep `update()` optimized
        function updateGlobals(timeStep, lag) {
            // Always try to fade out speed bar
            if (!isUpdatingSpeed) {
                speedBarOpacity -= lag / 30; // half a second
                if (speedBarOpacity < 0) {
                    speedBarOpacity = 0;
                }
            }
            // auto launch shells
            if (store.state.config.autoLaunch) {
                autoLaunchTime -= timeStep;
                if (autoLaunchTime <= 0) {
                    autoLaunchTime = startSequence();
                }
            }
        }


        function update(frameTime, lag) {
            if (!canInteract()) return;
            const {
                width,
                height
            } = mainStage;
            const timeStep = frameTime * simSpeed;
            const speed = simSpeed * lag;
            updateGlobals(timeStep, lag);
            const starDrag = 1 - (1 - Star.airDrag) * speed;
            const starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed;
            const sparkDrag = 1 - (1 - Spark.airDrag) * speed;
            const gAcc = timeStep / 1000 * GRAVITY;
            COLOR_CODES_W_INVIS.forEach(color => {
                // Stars
                Star.active[color].forEach((star, i, stars) => {
                    star.life -= timeStep;
                    if (star.life <= 0) {
                        stars.splice(i, 1);
                        Star.returnInstance(star);
                    } else {
                        star.prevX = star.x;
                        star.prevY = star.y;
                        star.x += star.speedX * speed;
                        star.y += star.speedY * speed;
                        // Apply air drag if star isn't "heavy". The heavy property is used for the shell comets.
                        if (!star.heavy) {
                            star.speedX *= starDrag;
                            star.speedY *= starDrag;
                        } else {
                            star.speedX *= starDragHeavy;
                            star.speedY *= starDragHeavy;
                        }
                        star.speedY += gAcc;
                        if (star.spinRadius) {
                            star.spinAngle += star.spinSpeed * speed;
                            star.x += Math.sin(star.spinAngle) * star.spinRadius * speed;
                            star.y += Math.cos(star.spinAngle) * star.spinRadius * speed;
                        }
                        if (star.sparkFreq) {
                            star.sparkTimer -= timeStep;
                            while (star.sparkTimer < 0) {
                                star.sparkTimer += star.sparkFreq;
                                Spark.add(
                                    star.x,
                                    star.y,
                                    star.sparkColor,
                                    Math.random() * PI_2,
                                    Math.random() * star.sparkSpeed,
                                    star.sparkLife * 0.8 + Math.random() * star.sparkLifeVariation *
                                    star.sparkLife
                                );
                            }
                        }
                    }
                });
                // Sparks
                Spark.active[color].forEach((spark, i, sparks) => {
                    spark.life -= timeStep;
                    if (spark.life <= 0) {
                        sparks.splice(i, 1);
                        Spark.returnInstance(spark);
                    } else {
                        spark.prevX = spark.x;
                        spark.prevY = spark.y;
                        spark.x += spark.speedX * speed;
                        spark.y += spark.speedY * speed;
                        spark.speedX *= sparkDrag;
                        spark.speedY *= sparkDrag;
                        spark.speedY += gAcc;
                    }
                });
            });
            render(speed);
        }

        function render(speed) {
            const {
                dpr,
                width,
                height
            } = mainStage;
            const trailsCtx = trailsStage.ctx;
            const mainCtx = mainStage.ctx;
            colorSky(speed);
            trailsCtx.scale(dpr, dpr);
            mainCtx.scale(dpr, dpr);
            trailsCtx.globalCompositeOperation = 'source-over';
            trailsCtx.fillStyle = `rgba(0, 0, 0, ${store.state.longExposure ? 0.0025 : 0.1 * speed})`;
            trailsCtx.fillRect(0, 0, width, height);
            // Remaining drawing on trails canvas will use 'lighten' blend mode
            trailsCtx.globalCompositeOperation = 'lighten';
            mainCtx.clearRect(0, 0, width, height);
            // Draw queued burst flashes
            while (BurstFlash.active.length) {
                const bf = BurstFlash.active.pop();
                const burstGradient = trailsCtx.createRadialGradient(bf.x, bf.y, 0, bf.x, bf.y, bf.radius);
                burstGradient.addColorStop(0.05, 'white');
                burstGradient.addColorStop(0.25, 'rgba(255, 160, 20, 0.2)');
                burstGradient.addColorStop(1, 'rgba(255, 160, 20, 0)');
                trailsCtx.fillStyle = burstGradient;
                trailsCtx.fillRect(bf.x - bf.radius, bf.y - bf.radius, bf.radius * 2, bf.radius * 2);
                BurstFlash.returnInstance(bf);
            }
            // Draw stars
            trailsCtx.lineWidth = Star.drawWidth;
            trailsCtx.lineCap = 'round';
            mainCtx.strokeStyle = '#fff';
            mainCtx.lineWidth = 1;
            mainCtx.beginPath();
            COLOR_CODES.forEach(color => {
                const stars = Star.active[color];
                trailsCtx.strokeStyle = color;
                trailsCtx.beginPath();
                stars.forEach(star => {
                    trailsCtx.moveTo(star.x, star.y);
                    trailsCtx.lineTo(star.prevX, star.prevY);
                    mainCtx.moveTo(star.x, star.y);
                    mainCtx.lineTo(star.x - star.speedX * 1.6, star.y - star.speedY * 1.6);
                });
                trailsCtx.stroke();
            });
            mainCtx.stroke();

            // Draw sparks
            trailsCtx.lineWidth = Spark.drawWidth;
            trailsCtx.lineCap = 'butt';
            COLOR_CODES.forEach(color => {
                const sparks = Spark.active[color];
                trailsCtx.strokeStyle = color;
                trailsCtx.beginPath();
                sparks.forEach(spark => {
                    trailsCtx.moveTo(spark.x, spark.y);
                    trailsCtx.lineTo(spark.prevX, spark.prevY);
                });
                trailsCtx.stroke();
            });
            // Render speed bar if visible
            if (speedBarOpacity) {
                const speedBarHeight = 6;
                mainCtx.globalAlpha = speedBarOpacity;
                mainCtx.fillStyle = COLOR.Blue;
                mainCtx.fillRect(0, height - speedBarHeight, width * simSpeed, speedBarHeight);
                mainCtx.globalAlpha = 1;
            }
            trailsCtx.resetTransform();
            mainCtx.resetTransform();
        }


        // Draw colored overlay based on combined brightness of stars (light up the sky!)
        // Note: this is applied to the canvas container's background-color, so it's behind the particles
        const currentSkyColor = {
            r: 0,
            g: 0,
            b: 0
        };
        const targetSkyColor = {
            r: 0,
            g: 0,
            b: 0
        };

        function colorSky(speed) {
            // The maximum r, g, or b value that will be used (255 would represent no maximum)
            const maxSkySaturation = 30;
            // How many stars are required in total to reach maximum sky brightness
            const maxStarCount = 500;
            let totalStarCount = 0;
            // Initialize sky as black
            targetSkyColor.r = 0;
            targetSkyColor.g = 0;
            targetSkyColor.b = 0;
            // Add each known color to sky, multiplied by particle count of that color. This will put RGB values wildly out of bounds, but we'll scale them back later.
            // Also add up total star count.
            COLOR_CODES.forEach(color => {
                const tuple = COLOR_TUPLES[color];
                const count = Star.active[color].length;
                totalStarCount += count;
                targetSkyColor.r += tuple.r * count;
                targetSkyColor.g += tuple.g * count;
                targetSkyColor.b += tuple.b * count;
            });
            // Clamp intensity at 1.0, and map to a custom non-linear curve. This allows few stars to perceivably light up the sky, while more stars continue to increase the brightness but at a lesser rate. This is more inline with humans' non-linear brightness perception.
            const intensity = Math.pow(Math.min(1, totalStarCount / maxStarCount), 0.3);
            // Figure out which color component has the highest value, so we can scale them without affecting the ratios.
            // Prevent 0 from being used, so we don't divide by zero in the next step.
            const maxColorComponent = Math.max(1, targetSkyColor.r, targetSkyColor.g, targetSkyColor.b);
            // Scale all color components to a max of `maxSkySaturation`, and apply intensity.
            targetSkyColor.r = targetSkyColor.r / maxColorComponent * maxSkySaturation * intensity;
            targetSkyColor.g = targetSkyColor.g / maxColorComponent * maxSkySaturation * intensity;
            targetSkyColor.b = targetSkyColor.b / maxColorComponent * maxSkySaturation * intensity;
            // Animate changes to color to smooth out transitions.
            const colorChange = 10;
            currentSkyColor.r += (targetSkyColor.r - currentSkyColor.r) / colorChange * speed;
            currentSkyColor.g += (targetSkyColor.g - currentSkyColor.g) / colorChange * speed;
            currentSkyColor.b += (targetSkyColor.b - currentSkyColor.b) / colorChange * speed;
            appNodes.canvasContainer.style.backgroundColor =
                `rgb(${currentSkyColor.r | 0}, ${currentSkyColor.g | 0}, ${currentSkyColor.b | 0})`;
        }

        mainStage.addEventListener('ticker', update);


        // Helper used to semi-randomly spread particles over an arc
        // Values are flexible - `start` and `arcLength` can be negative, and `randomness` is simply a multiplier for random addition.
        function createParticleArc(start, arcLength, count, randomness, particleFactory) {
            const angleDelta = arcLength / count;
            // Sometimes there is an extra particle at the end, too close to the start. Subtracting half the angleDelta ensures that is skipped.
            // Would be nice to fix this a better way.
            const end = start + arcLength - (angleDelta * 0.5);
            if (end > start) {
                // Optimization: `angle=angle+angleDelta` vs. angle+=angleDelta
                // V8 deoptimises with let compound assignment
                for (let angle = start; angle < end; angle = angle + angleDelta) {
                    particleFactory(angle + Math.random() * angleDelta * randomness);
                }
            } else {
                for (let angle = start; angle > end; angle = angle + angleDelta) {
                    particleFactory(angle + Math.random() * angleDelta * randomness);
                }
            }
        }


        // Various star effects.
        // These are designed to be attached to a star's `onDeath` event.

        // Crossette breaks star into four same-color pieces which branch in a cross-like shape.
        function crossetteEffect(star) {
            const startAngle = Math.random() * PI_HALF;
            createParticleArc(startAngle, PI_2, 4, 0.5, (angle) => {
                Star.add(
                    star.x,
                    star.y,
                    star.color,
                    angle,
                    Math.random() * 0.6 + 0.75,
                    600
                );
            });
        }

        // Flower is like a mini shell
        function floralEffect(star) {
            const startAngle = Math.random() * PI_HALF;
            createParticleArc(startAngle, PI_2, 24, 1, (angle) => {
                Star.add(
                    star.x,
                    star.y,
                    star.color,
                    angle,
                    // apply near cubic falloff to speed (places more particles towards outside)
                    Math.pow(Math.random(), 0.45) * 2.4,
                    1000 + Math.random() * 300,
                    star.speedX,
                    star.speedY
                );
            });
            // Queue burst flash render
            BurstFlash.add(star.x, star.y, 24);
        }

        // Floral burst with willow stars
        function fallingLeavesEffect(star) {
            const startAngle = Math.random() * PI_HALF;
            createParticleArc(startAngle, PI_2, 12, 1, (angle) => {
                const newStar = Star.add(
                    star.x,
                    star.y,
                    INVISIBLE,
                    angle,
                    // apply near cubic falloff to speed (places more particles towards outside)
                    Math.pow(Math.random(), 0.45) * 2.4,
                    2400 + Math.random() * 600,
                    star.speedX,
                    star.speedY
                );
                newStar.sparkColor = COLOR.Gold;
                newStar.sparkFreq = 72;
                newStar.sparkSpeed = 0.28;
                newStar.sparkLife = 750;
                newStar.sparkLifeVariation = 3.2;
            });
            // Queue burst flash render
            BurstFlash.add(star.x, star.y, 24);
        }

        // Crackle pops into a small cloud of golden sparks.
        function crackleEffect(star) {
            createParticleArc(0, PI_2, 10, 1.8, (angle) => {
                Spark.add(
                    star.x,
                    star.y,
                    COLOR.Gold,
                    angle,
                    // apply near cubic falloff to speed (places more particles towards outside)
                    Math.pow(Math.random(), 0.45) * 2.4,
                    300 + Math.random() * 200
                );
            });
        }



        /**
         * Shell can be constructed with options:
         *
         * size:      Size of the burst.
         * starCount: Number of stars to create. This is optional, and will be set to a reasonable quantity for size if omitted.
         * starLife:
         * starLifeVariation:
         * color:
         * glitterColor:
         * glitter: One of: 'light', 'medium', 'heavy', 'streamer', 'willow'
         * pistil:
         * pistilColor:
         * streamers:
         * crossette:
         * floral:
         * crackle:
         */

        class Shell {
            constructor(options) {
                Object.assign(this, options);
                this.starLifeVariation = options.starLifeVariation || 0.125;
                this.color = options.color || randomColor();
                this.glitterColor = options.glitterColor || this.color;
                // Set default starCount if needed, will be based on shell size and scale exponentially, like a sphere's surface area.
                if (!this.starCount) {
                    const density = options.starDensity || 1;
                    const scaledSize = this.size / 50 * density;
                    this.starCount = scaledSize * scaledSize;
                }
            }
            launch(position, launchHeight) {
                const {
                    width,
                    height
                } = mainStage;
                // Distance from sides of screen to keep shells.
                const hpad = 60;
                // Distance from top of screen to keep shell bursts.
                const vpad = 50;
                // Minimum burst height, as a percentage of stage height
                const minHeightPercent = 0.45;
                // Minimum burst height in px
                const minHeight = height - height * minHeightPercent;
                const launchX = position * (width - hpad * 2) + hpad;
                const launchY = height;
                const burstY = minHeight - (launchHeight * (minHeight - vpad));
                const launchDistance = launchY - burstY;
                // Using a custom power curve to approximate Vi needed to reach launchDistance under gravity and air drag.
                // Magic numbers came from testing.
                const launchVelocity = Math.pow(launchDistance * 0.04, 0.64);
                const comet = this.comet = Star.add(
                    launchX,
                    launchY,
                    typeof this.color === 'string' && this.color !== 'random' ? this.color : COLOR.White,
                    Math.PI,
                    launchVelocity * (this.horsetail ? 1.2 : 1),
                    // Hang time is derived linearly from Vi; exact number came from testing
                    launchVelocity * (this.horsetail ? 100 : 400)
                );
                // making comet "heavy" limits air drag
                comet.heavy = true;
                // comet spark trail
                comet.spinRadius = 0.78;
                comet.sparkFreq = 16;
                if (this.glitter === 'willow' || this.fallingLeaves) {
                    comet.sparkFreq = 10;
                    comet.sparkSpeed = 0.5;
                    comet.sparkLife = 500;
                    comet.sparkLifeVariation = 3;
                }
                if (this.color === INVISIBLE) {
                    comet.sparkColor = COLOR.Gold;
                }
                comet.onDeath = comet => this.burst(comet.x, comet.y);
                // comet.onDeath = () => this.burst(launchX, burstY);
            }
            burst(x, y) {
                // Set burst speed so overall burst grows to set size. This specific formula was derived from testing, and is affected by simulated air drag.
                const speed = this.size / 96;

                let color, onDeath, sparkFreq, sparkSpeed, sparkLife;
                let sparkLifeVariation = 0.25;
                if (this.crossette) onDeath = crossetteEffect;
                if (this.floral) onDeath = floralEffect;
                if (this.crackle) onDeath = crackleEffect;
                if (this.fallingLeaves) onDeath = fallingLeavesEffect;
                if (this.glitter === 'light') {
                    sparkFreq = 200;
                    sparkSpeed = 0.25;
                    sparkLife = 600;
                } else if (this.glitter === 'medium') {
                    sparkFreq = 100;
                    sparkSpeed = 0.36;
                    sparkLife = 1400;
                } else if (this.glitter === 'heavy') {
                    sparkFreq = 42;
                    sparkSpeed = 0.62;
                    sparkLife = 2800;
                } else if (this.glitter === 'streamer') {
                    sparkFreq = 20;
                    sparkSpeed = 0.75;
                    sparkLife = 800;
                } else if (this.glitter === 'willow') {
                    sparkFreq = 72;
                    sparkSpeed = 0.28;
                    sparkLife = 1000;
                    sparkLifeVariation = 3.4;
                }
                const starFactory = angle => {
                    const star = Star.add(
                        x,
                        y,
                        color || randomColor(),
                        angle,
                        // apply near cubic falloff to speed (places more particles towards outside)
                        Math.pow(Math.random(), 0.45) * speed,
                        // add minor variation to star life
                        this.starLife + Math.random() * this.starLife * this.starLifeVariation,
                        this.horsetail && this.comet && this.comet.speedX,
                        this.horsetail && this.comet && this.comet.speedY
                    );

                    star.onDeath = onDeath;

                    if (this.glitter) {
                        star.sparkFreq = sparkFreq;
                        star.sparkSpeed = sparkSpeed;
                        star.sparkLife = sparkLife;
                        star.sparkLifeVariation = sparkLifeVariation;
                        star.sparkColor = this.glitterColor;
                        star.sparkTimer = Math.random() * star.sparkFreq;
                    }
                };
                if (typeof this.color === 'string') {
                    if (this.color === 'random') {
                        color = null; // falsey value creates random color in starFactory
                    } else {
                        color = this.color;
                    }
                    // Rings have positional randomness, but are rotated randomly
                    if (this.ring) {
                        const ringStartAngle = Math.random() * Math.PI;
                        const ringSquash = Math.pow(Math.random(), 0.45) * 0.992 + 0.008;
                        createParticleArc(0, PI_2, this.starCount, 0, angle => {
                            // Create a ring, squashed horizontally
                            const initSpeedX = Math.sin(angle) * speed * ringSquash;
                            const initSpeedY = Math.cos(angle) * speed;
                            // Rotate ring
                            const newSpeed = MyMath.pointDist(0, 0, initSpeedX, initSpeedY);
                            const newAngle = MyMath.pointAngle(0, 0, initSpeedX, initSpeedY) +
                                ringStartAngle;
                            const star = Star.add(
                                x,
                                y,
                                color,
                                newAngle,
                                // apply near cubic falloff to speed (places more particles towards outside)
                                newSpeed, //speed,
                                // add minor variation to star life
                                this.starLife + Math.random() * this.starLife * this.starLifeVariation
                            );
                            if (this.glitter) {
                                star.sparkFreq = sparkFreq;
                                star.sparkSpeed = sparkSpeed;
                                star.sparkLife = sparkLife;
                                star.sparkLifeVariation = sparkLifeVariation;
                                star.sparkColor = this.glitterColor;
                                star.sparkTimer = Math.random() * star.sparkFreq;
                            }
                        });
                    }
                    // "Normal burst
                    else {
                        createParticleArc(0, PI_2, this.starCount, 1, starFactory);
                    }
                } else if (Array.isArray(this.color)) {
                    let start, start2, arc;
                    if (Math.random() < 0.5) {
                        start = Math.random() * Math.PI;
                        start2 = start + Math.PI;
                        arc = Math.PI;
                    } else {
                        start = 0;
                        start2 = 0;
                        arc = PI_2;
                    }
                    color = this.color[0];
                    createParticleArc(start, arc, this.starCount / 2, 1, starFactory);
                    color = this.color[1];
                    createParticleArc(start2, arc, this.starCount / 2, 1, starFactory)
                }
                if (this.pistil) {
                    const innerShell = new Shell({
                        size: this.size * 0.5,
                        starLife: this.starLife * 0.7,
                        starLifeVariation: this.starLifeVariation,
                        starDensity: 1.65,
                        color: this.pistilColor,
                        glitter: 'light',
                        glitterColor: this.pistilColor === COLOR.Gold ? COLOR.Gold : COLOR.White
                    });
                    innerShell.burst(x, y);
                }
                if (this.streamers) {
                    const innerShell = new Shell({
                        size: this.size,
                        starLife: this.starLife * 0.8,
                        starLifeVariation: this.starLifeVariation,
                        starCount: Math.max(6, this.size / 45) | 0,
                        color: COLOR.White,
                        glitter: 'streamer'
                    });
                    innerShell.burst(x, y);
                }
                // Queue burst flash render
                BurstFlash.add(x, y, this.size / 8);
            }
        }



        const BurstFlash = {
            active: [],
            _pool: [],
            _new() {
                return {}
            },
            add(x, y, radius) {
                const instance = this._pool.pop() || this._new();
                instance.x = x;
                instance.y = y;
                instance.radius = radius;
                this.active.push(instance);
                return instance;
            },
            returnInstance(instance) {
                this._pool.push(instance);
            }
        };



        // Helper to generate objects for storing active particles.
        // Particles are stored in arrays keyed by color (code, not name) for improved rendering performance.
        function createParticleCollection() {
            const collection = {};
            COLOR_CODES_W_INVIS.forEach(color => {
                collection[color] = [];
            });
            return collection;
        }

        const Star = {
            // Visual properties
            drawWidth: 3,
            airDrag: 0.98,
            airDragHeavy: 0.992,
            // Star particles will be keyed by color
            active: createParticleCollection(),
            _pool: [],
            _new() {
                return {};
            },

            add(x, y, color, angle, speed, life, speedOffX, speedOffY) {
                const instance = this._pool.pop() || this._new();
                instance.heavy = false;
                instance.x = x;
                instance.y = y;
                instance.prevX = x;
                instance.prevY = y;
                instance.color = color;
                instance.speedX = Math.sin(angle) * speed + (speedOffX || 0);
                instance.speedY = Math.cos(angle) * speed + (speedOffY || 0);
                instance.life = life;
                instance.spinAngle = Math.random() * PI_2;
                instance.spinSpeed = 0.8;
                instance.spinRadius = 0;
                instance.sparkFreq = 0; // ms between spark emissions
                instance.sparkSpeed = 1;
                instance.sparkTimer = 0;
                instance.sparkColor = color;
                instance.sparkLife = 750;
                instance.sparkLifeVariation = 0.25;
                this.active[color].push(instance);
                return instance;
            },

            // Public method for cleaning up and returning an instance back to the pool.
            returnInstance(instance) {
                // Call onDeath handler if available (and pass it current star instance)
                instance.onDeath && instance.onDeath(instance);
                // Clean up
                instance.onDeath = null;
                // Add back to the pool.
                this._pool.push(instance);
            }
        };


        const Spark = {
            // Visual properties
            drawWidth: 0.75,
            airDrag: 0.9,
            // Star particles will be keyed by color
            active: createParticleCollection(),
            _pool: [],
            _new() {
                return {};
            },

            add(x, y, color, angle, speed, life) {
                const instance = this._pool.pop() || this._new();
                instance.x = x;
                instance.y = y;
                instance.prevX = x;
                instance.prevY = y;
                instance.color = color;
                instance.speedX = Math.sin(angle) * speed;
                instance.speedY = Math.cos(angle) * speed;
                instance.life = life;
                this.active[color].push(instance);
                return instance;
            },

            // Public method for cleaning up and returning an instance back to the pool.
            returnInstance(instance) {
                // Add back to the pool.
                this._pool.push(instance);
            }
        };

        function init() {
            // Populate dropdowns
            // shell type
            let options = '';
            shellNames.forEach(opt => options += options += '<option value="' + opt + '">' + opt + '</option>');
            appNodes.shellType.innerHTML = options;
            // shell size
            options = '';
            ['3"', '5"', '6"', '8"', '12"'].forEach((opt, i) => options += '<option value="' + opt + '">' + opt +
                '</option>');
            appNodes.shellSize.innerHTML = options;

            renderApp(store.state);
        }
    </script>

</body>

</html>